Allow freezing model with cache compiled functions - #8330
Conversation
|
@ricardoV94 so this is what Opus came up with. Reviewed the code and it looks quite clean and sensible. Worth noting a call it made and I agree with is only caching compiled code, not graph construction. Initial graph construction tends to be fast and I don't see a real reason to cache it. LMK if you disagree. But as you said - with caching, the hard part is being sure the invalidations are all there, and this is really hard to assess without being very intimate with the codebase - so this part definitely needs your review. |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #8330 +/- ##
==========================================
+ Coverage 91.82% 91.84% +0.01%
==========================================
Files 128 128
Lines 21111 21227 +116
==========================================
+ Hits 19385 19495 +110
- Misses 1726 1732 +6
🚀 New features to boost your workflow:
|
ef13a55 to
42b0dbf
Compare
|
It's a bit annoying that |
0475a4d to
137aa98
Compare
Honestly two level caching feels like overkill here. I think fixed shape is likely the most common use case anyway, and it can be used for non-fixed sizes as well by using max shape and using a mask in many other cases. And, if there is ever a lot of demand, it can be a follow-up PR. |
|
In fixing the tests, it seems setting I don't think (b) is a good option, but (c) is worth considering as initvals graph is likely to be tiny under most use cases I can think of (including the ones we have, to my knowledge) and it would reduce change surface a fair bit. @ricardoV94 your thoughts? |
|
We should change the tests, there's one API for updating initvals and that's the one that should be used. Similar for all dicts/list mutation. Some codepaths also used to override rvs_to_transforms, but we have a model transform for that, which is the right API |
|
There are other dicts/lists in a Model that people may use, it's not unique to initvals imo |
|
So you want to go down that (b) route and have the thing break if users ever try to directly modify the internal dicts instead of using the appropriate setters? You sure? |
|
I want to see how that turns out. Not sure at all :D |
|
Nice to know you like living on the edge :D Then again - we are talking about bugs that only come up if someone samples the same model twice, which is something that does not come up all that often, and usually with power users who can likely figure it out. So it's most likely fine. |
|
@ricardoV94 this is ready from my side and tests are green. Let me know if you want any changes |
|
@ricardoV94 gentle reminder this is still waiting. It solved a pretty big performance issue for us, so it would be nice if it got merged somewhere in the next few weeks :) |
|
Bumped closer to the top of my stack ;) |
|
|
||
| f1 = m.compile_fn(mu, inputs=[], point_fn=False) | ||
| with m: | ||
| m.set_data("x", np.ones(3)) |
There was a problem hiding this comment.
one foot gun now is if users do x.set_value() directly. I'm really bummed that updating shared variable values has to invalidate cache... and I'm thinking whether we should have one layer of work on the user. an explicit cached_model = model.cache() so we have a place to add docstrings telling users about how cache works and cache invalidation?
Technically setting data without changing size can also invalidate initial point as well
There was a problem hiding this comment.
As we don't compile initval, this seems to not be an issue.
As for documentation, added it to the "Model" right now. Seems like a decently right place, but maybe a bit too overloaded already. LMK if this works for you or if you want it separated somehow
| with pm.Model() as m: | ||
| pm.Normal("x", 0, 1, size=3, initval="prior") # random initval | ||
|
|
||
| ip1 = m.initial_point(1) |
There was a problem hiding this comment.
does initial_point seed only accept integer?
There was a problem hiding this comment.
integer or a list or ndarray of ints. Generator not supported, but supposedly this is the present state and not a change in this PR.
|
|
||
| The cache is cleared automatically whenever the model graph is mutated through its | ||
| public API (``register_rv``, ``add_coord(s)``, ``set_dim``, ``set_initval``, | ||
| ``register_data_var``, ...). A value-only ``set_data`` keeps the cache (data is a |
There was a problem hiding this comment.
(data is a runtime input), makes it sound like it is safe. But set_data can influence things that are cached like initial_point and even the shape of variables, without its shape also changing.
There was a problem hiding this comment.
Yeah. I also managed to forget that size can be determined at run-time based on data values provided, so resize detection is not foolproof.
AI recommended clearing the size-baking caches (_logp_dlogp_function) on every set_data, but keeping _compile_fn (the forward-sampling/headline cache) when the data's own shape is unchanged. Sounds like it makes sense, but I am far too much out of my depth to be sure here. How does it sound to you?
|
The test failures do look like they are main branch flakes, as they replicate on current main as well. Anything here left for me to do? |
Remove the two tests that asserted on compile and graph-walk counts: they pinned implementation details rather than behaviour. Only collect a compiled function's RNG inputs that are SharedVariables, since those are the ones that can be reseeded. Match the codebase style: trim the multi-line comments to the essentials, drop a code comment that referred to the PR discussion, and type the extracted initial values as pymc types them elsewhere. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
(comment drafted by AI) All three blocking items are fixed, and I dug into both follow-ups. 1. Nested model mutating the frozen model. Reproduced exactly. A sub-model shares its parent's variable containers, so registering into it wrote straight through to the frozen parent. 2. Extra initial-point compile. Fixed — the caller's point is passed down to the cached This also turned up a latent bug: 3. Redundant graph walks. 4. Remaining
Both need a cache-key fix (identity/hash-based equality) rather than a call-site change, so I left them as genuine follow-ups. Repeated frozen 5. Downstream. nutpie — now the default sampler — works on Also removed the two internals tests, filtered the RNG lookup to Why did the sub-model get thrown out of the |
`pytensorf.compile` calls `get_mode` on the same argument anyway, so swallowing its error in the linker check only moved the failure to a less obvious place. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
(comment drafted by AI) Follow-up 4 is done after all — I'd called it separate work too early. Both halves were blocked by the same thing, three small gaps in the cache-key helpers:
With those, routing The two Why did the cache keep missing? It kept changing its mind about what a set was worth. Sets are unordered, so it just couldn't put a finger on it. 🫰 |
|
(Real Margus) I pushed it to look into the follow ups too. The hash things it found look like a legitimate improvement, but as they seem to touch more central machinery, that definitely warrants your review. LMK if you want me to split them off like AI proposed. Also. It just checked nutpie to confirm it should work with it (kind of important as that is the default sampler...). As for Bambi, as it just constructs models itself, it's unlikely to matter there unless they start using it :) |
|
Splitting will speedup the merging of this PR, as I was already on board with the previous state + tweaks |
|
(comment drafted by AI) Split off into #8383 — the three Once #8383 lands, a small follow-up wires One correction to my earlier comment: I reported the variational suite as "286 passed" when validating those fixes. It is 286 passed and 49 failed — I had grepped only the pass line. The failures are identical on a clean Why did the cache key finally get let into the club? It had a hash to get past the bouncer, and this time it wasn't unordered. 🎟️ |
Reading the random variables off the compiled function returns them in a different order than `pymc.pytensorf.compile` collects them, and `reseed_rngs` hands out sub-seeds by position, so every variable silently got another variable's stream. Prior predictive draws from a model with more than one variable no longer matched an uncached compile, which `test_car_rng_fn` caught by comparing CAR draws against MvNormal ones. Collect them the way `compile` does instead, and check a cached compile against an uncached one directly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
(comment drafted by AI) The
Fixed by collecting them the way That does mean the second walk is back, so your point 3 is only half addressed: the walk is skipped entirely on a cache hit, but a fresh compile still pays two. Handing the updates back from The two remaining red suites here fail identically on a clean Why did the random variables keep getting each other's seeds? Nobody had told them the order of operations — they just kept drawing their own conclusions. 🎲 |
|
You can ask pymc.pytensorf.compile to return the updates (some new return_updates: bool)? I imagine it could be useful regardless? |
|
(comment drafted by AI) Done — It is useful beyond this, agreed: anything that wants to reseed or inspect a function's RNGs previously had to re-derive them, and re-deriving them in a different order than One knock-on: Why did |
`compile` walks the graph to build the RNG update mapping, and callers that need those variables had to walk it a second time to get them in the same order. Add `return_updates`, and use it for the cached compilation, which needs the order `compile` would have seeded in. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ad19a88 to
5017f43
Compare
|
(comment drafted by AI) Both failures were fallout from
Locally: 349 passed across Why did |
| return fn | ||
|
|
||
| def compile_fn( | ||
| def _compile_fn( |
There was a problem hiding this comment.
I don't love creating a fake _compile_fn in the base model and making compile_fn more complex. I hadn't quite got the design. The basemodel (and regular model) is contorted clearly to accomodate the frozenmodel.
Instead I would not define compile_fn in the base model, let the regular model keep the old one, and implement the two tier version manually in the FrozenModel. It's not much more code and it's more honest.
Risk of drift is slightly larger but I think I'd prefer it.
`compile_fn` was carrying the caching in the base model, through a private method that only existed to give the cache something to wrap, so every model paid for a feature only frozen models use. Declare it in the base model and implement it twice instead: `Model` keeps the plain compile, and `FrozenModel` implements the two-tier version, holding its own cached compile. Duplicating the two of them is more code but says what each model actually does. The tests that patch `compile` go back to what they are on main, since the path they exercise is unchanged again. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
(comment drafted by AI) Restructured as you asked, and you were right that the design was the problem rather than the details.
Two things fell out that argue for your version over mine:
One deliberate difference from
Why did the base model finally relax? It stopped trying to be everything to everyone and learned to delegate. Turns out it just needed better class boundaries. 🎓 |
Let's clean those up in a follow up, I think this is good to start testing |
|
Thank you for all the attention @ricardoV94 |
@velochy can you open an issue to track this? |
|
(comment drafted by AI) Opened #8385 with the change itself rather than an issue — it turned out small enough that describing it would have taken about as long as doing it.
It is stacked on #8383 because that PR changes Happy to convert it back to an issue if you would rather keep the queue short. Why did the base model stop keeping private methods around for its subclass? It realised they were just holding a seam for someone else. 🧵 |
Cache compiled model functions on frozen models
Description
pm.sample_posterior_predictive/sample_prior_predictive,logp_dlogp_function, andthe initial-point function recompile on every call, even when the model graph is fixed and
only data values change. This dominates wall time in iterative workflows — projecting a
posterior onto a population in many batches with changing
pm.set_data, or repeatedlycalling
pm.sampleon one model.Caching is opt-in through a new model transform (as proposed in review):
freeze_model(model)returns a frozen copy that memoizes the graphs and compiledfunctions it builds (
logp/dlogp/d2logp,compile_fn,logp_dlogp_function,initial_point, and the forward-sampling function). Because a frozen model cannot bemutated, no cache invalidation is needed — graph-mutating methods (
register_rv,add_coord,set_initval, ...) raise, and the dims/data that any free variable dependson are frozen to constants via
freeze_dims_and_data, so nothing thatinitial_pointorlogp_dlogp_functionbake in can change.pm.set_datavalue updates and resizes are runtime inputs of the cached functions andtake effect without recompiling.
set_data/set_dimand allmutation behave exactly as on
main.(
compile(random_seed=False)) and are reseeded on everycompile_fncall, in the sameorder
compileitself uses, so a cached function yields the same RNG stream as a freshcompile. Functions with RNGs compiled to linkers that detach RNG shared variables at
compile time (JAX/MLX/PyTorch) cannot be reseeded and are compiled fresh each call;
RNG-free functions are cached on all backends.
compile_forward_sampling_functiongains amodel=argument so forward sampling routesthrough the cache.
Reusing cached RNG functions on JAX-family backends would need a pytensor-level way to
update a compiled function's detached RNG variables (see pymc-devs/pytensor#2271 for a first
attempt); until then those are compiled per call.
Related Issue
Checklist
mutation,
set_datavalue/resize reuse, reseeding, JAX bypass, sppc reuse acrossset_data, same-seed reproducibility)Type of change